Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit e8f6a4e0592d7b63e82b6ca52993dbb507f03347


Parents : c59e50a
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-23T04:51:12-05:00

feat(security): improve URL handling in ConversationViewer and RNCPPage to block unsafe links and validate hashes

Changes
Diff

diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index 096cd71d..dc362a70 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -1521,6 +1521,7 @@ import ToastUtils from "../../js/ToastUtils";
import PaperMessageModal from "./PaperMessageModal.vue";
import GlobalState from "../../js/GlobalState";
import MarkdownRenderer from "../../js/MarkdownRenderer";
+import LinkUtils from "../../js/LinkUtils";
import { findMapUriInContent, mapLinkKindFromMessage, parseMeshchatMapUri } from "../../js/mapLinkUtils.js";
import { COLUMBA_REACTION_EMOJIS, mergeLxmfReactionRowsIntoMessages } from "../../js/lxmfReactions";
import { createOutboundQueue } from "../../js/outboundSendQueue";
@@ -2161,6 +2162,7 @@ export default {
return base;
},
async handleMessageClick(event) {
+ const hex32 = /^[a-fA-F0-9]{32}$/;
const nomadnetLink = event.target.closest(".nomadnet-link");
if (nomadnetLink) {
event.preventDefault();
@@ -2168,6 +2170,9 @@ export default {
if (url) {
const [hash, ...pathParts] = url.split(":");
const path = pathParts.join(":");
+ if (!hex32.test(hash)) {
+ return;
+ }
const routeName = this.$route.meta.isPopout ? "nomadnetwork-popout" : "nomadnetwork";
this.$router.push({
name: routeName,
@@ -2182,7 +2187,7 @@ export default {
if (lxmfLink) {
event.preventDefault();
const address = lxmfLink.getAttribute("data-lxmf-address");
- if (address) {
+ if (address && hex32.test(address)) {
this.$router.push({
name: "messages",
params: { destinationHash: address },
@@ -2196,8 +2201,9 @@ export default {
return;
}
- const href = String(standardLink.getAttribute("href") || "").trim();
- if (!/^https?:\/\//i.test(href)) {
+ const hrefRaw = String(standardLink.getAttribute("href") || "").trim();
+ const safeHttp = LinkUtils.httpUrlHrefOrNull(hrefRaw);
+ if (!safeHttp) {
event.preventDefault();
return;
}
@@ -2205,14 +2211,14 @@ export default {
event.preventDefault();
if (this.isStrangerPeer && this.warnOnStrangerLinksEnabled) {
const proceed = await DialogUtils.confirm(
- this.$t("messages.stranger_link_open_confirm", { url: href })
+ this.$t("messages.stranger_link_open_confirm", { url: safeHttp })
);
if (!proceed) {
return;
}
}
- window.open(href, "_blank", "noopener");
+ window.open(safeHttp, "_blank", "noopener,noreferrer");
},
async updatePropagationNodeStatus() {
try {

diff --git a/meshchatx/src/frontend/components/rncp/RNCPPage.vue b/meshchatx/src/frontend/components/rncp/RNCPPage.vue
index ec24ec1b..a108337d 100644
--- a/meshchatx/src/frontend/components/rncp/RNCPPage.vue
+++ b/meshchatx/src/frontend/components/rncp/RNCPPage.vue
@@ -836,6 +836,7 @@ export default {
return MarkdownRenderer.render(text);
},
handleMessageClick(event) {
+ const hex32 = /^[a-fA-F0-9]{32}$/;
const nomadnetLink = event.target.closest(".nomadnet-link");
if (nomadnetLink) {
event.preventDefault();
@@ -843,19 +844,22 @@ export default {
if (url) {
const [hash, ...pathParts] = url.split(":");
const path = pathParts.join(":");
- this.$router.push({
- name: "nomadnetwork",
- params: { destinationHash: hash },
- query: { path: path },
- });
+ if (hex32.test(hash)) {
+ this.$router.push({
+ name: "nomadnetwork",
+ params: { destinationHash: hash },
+ query: { path: path },
+ });
+ }
}
+ return;
}
const lxmfLink = event.target.closest(".lxmf-link");
if (lxmfLink) {
event.preventDefault();
const address = lxmfLink.getAttribute("data-lxmf-address");
- if (address) {
+ if (address && hex32.test(address)) {
this.$router.push({
name: "messages",
params: { destinationHash: address },

diff --git a/meshchatx/src/frontend/js/KeyboardShortcuts.js b/meshchatx/src/frontend/js/KeyboardShortcuts.js
index d6bd99d3..0baf239b 100644
--- a/meshchatx/src/frontend/js/KeyboardShortcuts.js
+++ b/meshchatx/src/frontend/js/KeyboardShortcuts.js
@@ -155,8 +155,11 @@ class KeyboardShortcuts {
}
setShortcuts(shortcuts) {
- // Merge with defaults to ensure all actions have a description
const defaults = this.getDefaultShortcuts();
+ if (!Array.isArray(shortcuts) || shortcuts.length === 0) {
+ this.shortcuts = defaults.map((d) => ({ ...d }));
+ return;
+ }
this.shortcuts = shortcuts.map((s) => {
const def = defaults.find((d) => d.action === s.action);
return {

diff --git a/tests/frontend/ConversationViewer.test.js b/tests/frontend/ConversationViewer.test.js
index ee26b445..8362dcc4 100644
--- a/tests/frontend/ConversationViewer.test.js
+++ b/tests/frontend/ConversationViewer.test.js
@@ -67,7 +67,7 @@ describe("ConversationViewer.vue", () => {
WebSocketConnection.destroy();
});
- const mountConversationViewer = (props = {}) => {
+ const mountConversationViewer = (props = {}, extraMocks = {}) => {
return mount(ConversationViewer, {
props: {
selectedPeer: { destination_hash: "test-hash", display_name: "Test Peer" },
@@ -79,6 +79,9 @@ describe("ConversationViewer.vue", () => {
directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } },
mocks: {
$t: (key) => key,
+ $route: { meta: {} },
+ $router: { push: vi.fn() },
+ ...extraMocks,
},
stubs: {
MaterialDesignIcon: true,
@@ -195,7 +198,7 @@ describe("ConversationViewer.vue", () => {
expect(event.preventDefault).toHaveBeenCalled();
expect(DialogUtils.confirm).toHaveBeenCalledWith("messages.stranger_link_open_confirm");
- expect(window.open).toHaveBeenCalledWith("https://example.com/path", "_blank", "noopener");
+ expect(window.open).toHaveBeenCalledWith("https://example.com/path", "_blank", "noopener,noreferrer");
});
it("does not open stranger http link when warning confirm is rejected", async () => {
@@ -232,7 +235,7 @@ describe("ConversationViewer.vue", () => {
expect(event.preventDefault).toHaveBeenCalled();
expect(DialogUtils.confirm).not.toHaveBeenCalled();
- expect(window.open).toHaveBeenCalledWith("https://example.com/path", "_blank", "noopener");
+ expect(window.open).toHaveBeenCalledWith("https://example.com/path", "_blank", "noopener,noreferrer");
});
it("blocks non-http href payloads like data urls in message anchors", async () => {
@@ -253,6 +256,124 @@ describe("ConversationViewer.vue", () => {
expect(window.open).not.toHaveBeenCalled();
});
+ it("blocks https-looking anchors that are not a single safe http(s) URL (LinkUtils)", async () => {
+ const wrapper = mountConversationViewer();
+ wrapper.vm.isStrangerPeer = false;
+ window.open.mockClear();
+
+ const anchor = document.createElement("a");
+ anchor.setAttribute("href", "https://example.com javascript:alert(1)");
+ const event = { target: anchor, preventDefault: vi.fn() };
+
+ await wrapper.vm.handleMessageClick(event);
+
+ expect(event.preventDefault).toHaveBeenCalled();
+ expect(window.open).not.toHaveBeenCalled();
+ });
+
+ it("blocks javascript: href in rendered message anchors", async () => {
+ const wrapper = mountConversationViewer();
+ window.open.mockClear();
+
+ const anchor = document.createElement("a");
+ anchor.setAttribute("href", "javascript:alert(1)");
+ const event = { target: anchor, preventDefault: vi.fn() };
+
+ await wrapper.vm.handleMessageClick(event);
+
+ expect(event.preventDefault).toHaveBeenCalled();
+ expect(window.open).not.toHaveBeenCalled();
+ });
+
+ it("does not router.push for nomadnet link when destination hash is not 32 hex chars", async () => {
+ const push = vi.fn();
+ const wrapper = mountConversationViewer(
+ {},
+ {
+ $router: { push },
+ $route: { meta: { isPopout: false } },
+ }
+ );
+
+ const a = document.createElement("a");
+ a.className = "nomadnet-link";
+ a.setAttribute("data-nomadnet-url", "not32hexchars:/page/index.mu");
+ const event = { target: a, preventDefault: vi.fn() };
+
+ await wrapper.vm.handleMessageClick(event);
+
+ expect(event.preventDefault).toHaveBeenCalled();
+ expect(push).not.toHaveBeenCalled();
+ });
+
+ it("router.push for valid nomadnet data-nomadnet-url", async () => {
+ const push = vi.fn();
+ const wrapper = mountConversationViewer(
+ {},
+ {
+ $router: { push },
+ $route: { meta: { isPopout: false } },
+ }
+ );
+ const hash = "1dfeb0d794963579bd21ac8f153c77a4";
+ const a = document.createElement("a");
+ a.className = "nomadnet-link";
+ a.setAttribute("data-nomadnet-url", `${hash}:/page/index.mu`);
+ const event = { target: a, preventDefault: vi.fn() };
+
+ await wrapper.vm.handleMessageClick(event);
+
+ expect(push).toHaveBeenCalledWith({
+ name: "nomadnetwork",
+ params: { destinationHash: hash },
+ query: { path: "/page/index.mu" },
+ });
+ });
+
+ it("does not router.push for lxmf link when address is not 32 hex chars", async () => {
+ const push = vi.fn();
+ const wrapper = mountConversationViewer(
+ {},
+ {
+ $router: { push },
+ $route: { meta: {} },
+ }
+ );
+
+ const a = document.createElement("a");
+ a.className = "lxmf-link";
+ a.setAttribute("data-lxmf-address", "abcdabcdabcdabcdabcdabcdabcdab");
+ const event = { target: a, preventDefault: vi.fn() };
+
+ await wrapper.vm.handleMessageClick(event);
+
+ expect(event.preventDefault).toHaveBeenCalled();
+ expect(push).not.toHaveBeenCalled();
+ });
+
+ it("router.push for valid lxmf data-lxmf-address", async () => {
+ const push = vi.fn();
+ const wrapper = mountConversationViewer(
+ {},
+ {
+ $router: { push },
+ $route: { meta: {} },
+ }
+ );
+ const hash = "1dfeb0d794963579bd21ac8f153c77a4";
+ const a = document.createElement("a");
+ a.className = "lxmf-link";
+ a.setAttribute("data-lxmf-address", hash);
+ const event = { target: a, preventDefault: vi.fn() };
+
+ await wrapper.vm.handleMessageClick(event);
+
+ expect(push).toHaveBeenCalledWith({
+ name: "messages",
+ params: { destinationHash: hash },
+ });
+ });
+
it("onComposerImageDrop ignores non-image files", () => {
const wrapper = mountConversationViewer();
const pdf = new File([""], "doc.pdf", { type: "application/pdf" });

diff --git a/tests/frontend/KeyboardShortcuts.test.js b/tests/frontend/KeyboardShortcuts.test.js
index f0a59289..e5615a4f 100644
--- a/tests/frontend/KeyboardShortcuts.test.js
+++ b/tests/frontend/KeyboardShortcuts.test.js
@@ -119,6 +119,21 @@ describe("KeyboardShortcuts", () => {
KeyboardShortcuts.setShortcuts(KeyboardShortcuts.getDefaultShortcuts());
});
+ it("setShortcuts ignores non-array payloads and keeps defaults", () => {
+ KeyboardShortcuts.setShortcuts(null);
+ dispatchKeyDown({ key: "1", altKey: true, code: "Digit1" });
+ expect(emitSpy).toHaveBeenCalledWith("keyboard-shortcut", "nav_messages");
+ KeyboardShortcuts.setShortcuts({ action: "nav_map", keys: ["alt", "3"] });
+ dispatchKeyDown({ key: "3", altKey: true, code: "Digit3" });
+ expect(emitSpy).toHaveBeenCalledWith("keyboard-shortcut", "nav_map");
+ });
+
+ it("setShortcuts treats empty array as reset to defaults", () => {
+ KeyboardShortcuts.setShortcuts([]);
+ dispatchKeyDown({ key: "2", altKey: true, code: "Digit2" });
+ expect(emitSpy).toHaveBeenCalledWith("keyboard-shortcut", "nav_nomad");
+ });
+
it("saveShortcut sends keyboard_shortcuts.set over WebSocket", async () => {
await KeyboardShortcuts.saveShortcut("nav_messages", ["alt", "q"]);
expect(wsSend).toHaveBeenCalledWith(

diff --git a/tests/frontend/MarkdownRenderer.test.js b/tests/frontend/MarkdownRenderer.test.js
index 559022c5..b8ea91d8 100644
--- a/tests/frontend/MarkdownRenderer.test.js
+++ b/tests/frontend/MarkdownRenderer.test.js
@@ -141,7 +141,7 @@ describe("MarkdownRenderer.js", () => {
});
});
- describe("security: XSS prevention", () => {
+ describe("security: XSS prevention (MarkdownRenderer output is v-html in ConversationMessageEntry)", () => {
it("escapes script tags", () => {
const malformed = "<script>alert('xss')</script>";
const result = MarkdownRenderer.render(malformed);


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────